実践Solidityプログラミング | Anonify解体新書7
前回の内容はこちら
TL;DR
セキュリティ・プライバシー保護技術Anonifyの主要技術要素について解説する連載記事(全8回)
Anonifyで使用されている技術要素を洗い出し重要な技術について簡単なサンプルプログラムを交えて解説する。
その6からはAnonifyが使用しているブロックチェーン技術について取り上げている
その7では「実践Solidityプログラミング」と題して、実用的なスマートコントラクト開発のTipsを取り上げる
サンプルプログラムはSolidityとJavaScriptを使用する
ここで使用するコードはすべて独立して動作するのでAnonify自体の知識やAnonifyの動作環境は不要
この記事の中で使用する図は特別な記載がない限り全て筆者が作成したもの
サンプルプログラムはこちら
Anonifyが使用している主な技術要素
TEE(Intel SGX)関連
OCall/ECall
Remote Attestation
crypto_box(NaCl)
データのシーリング
mutual-TLS
ブロックチェーン関連
スマートコントラクト <- 今回解説するのはここ(前回と同じ)
Web3
開発環境をTruffleからHardHatに移行する
前回の記事執筆時点において筆者がHardHatの存在を知らなかったためTruffleをおすすめしてしまった
追加調査によりHardHatの方が良いと判断
こちらの記事をきっかけにHardHatの存在を知った
HardHatはTruffleに変わるイーサリアム開発環境
プライベートネットはHardhat Networkと呼ばれるGanacheライクなものを採用している
プラグインをインストールすればプライベートネットにGanacheを使うことも可能
スマートコントラクト開発環境のデファクトがTruffleだったのは少し前の話(2018年2020年くらいまで)で、最近はHardHatの人気が高まっている&新たなデファクトになりつつある
最近のダウンロード数はTruffleよりも多い
前回スマートコントラクトの開発環境としてTruffleをおすすめしたが、以下の理由によりHardHatをおすすめする
TruffleはSolidityプログラムでログ出力ができない
HardHatは可能
Truffleはテスト実行が遅い
HardHatはキャッシュの仕組みがありコンパイルやテスト実行がTruffleに比べて高速
TruffleはTypeScriptが使えない
HardHatは可能
HardHatにおけるコントラクトのテスト
TruffleにしてもHardHatにしても基本はテストを書いてコントラクトの実行を確認するという流れは同じ
HardHatもTruffleと同じでmochaでテストする(ただしTruffleのcontractのような拡張機能はなさそう)
HardHatはアサーションにchaiのexpect関数を使うことを推奨している
HardHatにおけるコントラクトのデプロイ
Truffleはデプロイのことをマイグレーション(Migration)の中の1作業という感じの扱いだったがHardHatにはマイグレーションという概念はなさそう
シンプルにコントラクトをデプロイするだけ
デプロイ用のJavaScriptプログラムを書いて実行するのは基本的に同じ
HardHat環境のセットアップ
HardHatのインストールとセットアップ
code: bash
$ npm install --save-dev hardhat
$ npx hardhat
npx hardhatを実行するとどこにプロジェクトを作るかを聞かれるのでgreeterディレクトリ以下に新規プロジェクトを作成するように答える(関連ライブラリも一緒にインストールする)
What do you want to do?の質問はCreate a basic sample projectを選択する
code: bash
👷 Welcome to Hardhat v2.6.7 👷
✔ What do you want to do? · Create a basic sample project
✔ Hardhat project root: · ./greeter
✔ Do you want to add a .gitignore? (Y/n) · y
✔ Do you want to install this sample project's dependencies with npm (@nomiclabs/hardhat-waffle ethereum-waffle chai @nomiclabs/hardhat-ethers ethers)? (Y/n) · y
自動生成された新規プロジェクトの構成は以下
Truffleと似た構成、migrateの代わりにscriptsディレクトリ
migrate専用のコントラクトは生成されない
code: bash
$ cd greeter
$ tree
/greeter
├── README.md
├── contracts
│ └── Greeter.sol # Solidityのコード
├── hardhat.config.js
├── scripts
│ └── sample-script.js # デプロイスクリプト
└── test
└── sample-test.js # テストコード
npx hardhat nodeコマンドでプライベートネットの起動ができる
開発環境が整ったので本編にもどる
より実践的なスマートコントラクト開発をするために
今回取り上げるスマートコントラクト開発のTipsは以下
ブロックチェーンにデータを保存する方法
Solidityプログラムからログを出力する方法
コントラクトに所有権を設定する方法
Satelliteパターンを使ったコントラクトの更新
ブロックチェーンにデータを保存する方法
Solidityでブロックチェーンにデータを保存する方法
コントラクトのメンバー変数はデフォルトでブロックチェーンに保存される
一般的なプログラミング言語(GoとかRustとかPythonとか)でデータベースにデータを登録するのとはだいぶ感覚が違うので初めは戸惑うかもしれない
code: Storage.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
contract Storage is Ownable {
// ブロックチェーンに保存されるデータ
string[] private values;
// データを追加する(データを追加できるのはオーナーのみ)
function addValue(string calldata v) public onlyOwner {
values.push(v);
}
function getValues() public view returns(string[] memory) {
return values;
}
}
テストを書く
testディレクトリにstorage-test.jsファイルを作成してテストを書く(自動生成されたsample-test.jsは削除する)
code: storage-test.js
const { expect } = require('chai');
const { ethers } = require('hardhat');
describe('Storage', function () {
it('Should increase value', async function () {
const Storage = await ethers.getContractFactory('Storage');
const storage = await Storage.deploy();
await storage.deployed();
// データが書き込まれてないことを確認する
expect((await storage.getValues()).length).to.equal(0);
// データを書き込む
await storage.addValue('one');
await storage.addValue('two');
// 書き込まれたデータを確認する
const values = await storage.getValues();
expect(values.length).to.equal(2);
expect(values0).to.equal('one'); expect(values1).to.equal('two'); });
});
データがブロックチェーンに書き込まれたか確認する
hardhat nodeコマンドでプライベートネットを起動する
code: bash
$ npx hardhat node
Accounts
========
Account #0: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266 (10000 ETH) Private Key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
... 省略 ...
テストを実行する
テストコマンドの--networkオプションにlocalhostを指定するとプライベートネット上でテストを実行することができる
code: bash
$ npx hardhat test --network localhost
Storage
✓ Should increase value (307ms)
1 passing (309ms)
テストを実行するとプライベートネットに以下のログが吐き出される
StorageコントラクトのaddValueが呼ばれると新たにトランザクションが生成されていることがわかる
0xf2b6d914d2e2297908836e742ea30dea117b97766abb1b6b8b9b250f6309bcc3
0xa7d99198349117987d7897e9a57beb3b8907112385e0f81fbc16e2e6dee7b51a
トランザクションが生成されるということはデータが書き込まれたということ
code: bash
web3_clientVersion (2)
eth_chainId
eth_accounts
eth_blockNumber
eth_chainId (2)
eth_estimateGas
eth_getBlockByNumber
eth_feeHistory
eth_sendTransaction
Contract deployment: Storage
Contract address: 0x5fbdb2315678afecb367f032d93f642f64180aa3
Transaction: 0x8e1b03b84a473ca83408ffb13c31952857d3ff757a079ad67c9e28fdb8d7a8db
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
Value: 0 ETH
Gas used: 320091 of 320091
Block #1: 0x8fe8769d28d67d6ed4d80c262f3f58d370b81c4a4437a6b314af7fefb3f332b1 eth_chainId
eth_getTransactionByHash
eth_chainId
eth_getTransactionReceipt
eth_chainId
eth_call
Contract call: Storage#getValues
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
To: 0x5fbdb2315678afecb367f032d93f642f64180aa3
eth_chainId
eth_estimateGas
eth_feeHistory
eth_sendTransaction
Contract call: Storage#addValue
Transaction: 0xf2b6d914d2e2297908836e742ea30dea117b97766abb1b6b8b9b250f6309bcc3
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
To: 0x5fbdb2315678afecb367f032d93f642f64180aa3
Value: 0 ETH
Gas used: 66739 of 66739
Block #2: 0x4c04b57f1c124fb6e16eacfc3d48435e256ff80ad478b29bba92187ae38eb8ab eth_chainId
eth_getTransactionByHash
eth_chainId
eth_estimateGas
eth_feeHistory
eth_sendTransaction
Contract call: Storage#addValue
Transaction: 0xa7d99198349117987d7897e9a57beb3b8907112385e0f81fbc16e2e6dee7b51a
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
To: 0x5fbdb2315678afecb367f032d93f642f64180aa3
Value: 0 ETH
Gas used: 49639 of 49639
Block #3: 0x7646cf9b2762669ac923bc12715cbf490dd98b4ce783080f3c4ce44573db68a7 eth_chainId
eth_getTransactionByHash
eth_chainId
eth_call
Contract call: Storage#getValues
From: 0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
To: 0x5fbdb2315678afecb367f032d93f642f64180aa3
hardhat consoleコマンドを使ってブロックチェーン上にあるブロックやトランザクションの内容を確認することができる
コンソールでethers.jsを使って各種データを確認することができる
ethers.provider.getBlockメソッドを使うとブロックの内容を確認することができる
ethers.provider.getTransactionメソッドを使うとトランザクションの内容を確認することができる
ブロックチェーンに書き込まれたデータを確認するにはdata属性をみるとよい
code: bash
$ npx hardhat console --network localhost
Welcome to Node.js v16.11.0.
Type ".help" for more information.
await ethers.provider.getBlockNumber() // ブロック高の確認
3
await ethers.provider.getBlock(3); // ブロックの内容確認
{
hash: '0x23a3bc197ea51df60c04ade5e7b2cd81430e42fb1ab6467c1be2cae5e9581d33',
parentHash: '0x2dca16f9fc67568ef5e22fa9b84ad6ec743a190e2d28f0252bc6534f247bbad3',
number: 3,
timestamp: 1635679768,
nonce: '0x0000000000000042',
difficulty: 131200,
gasLimit: BigNumber { _hex: '0x01c9c380', _isBigNumber: true },
gasUsed: BigNumber { _hex: '0xc1e7', _isBigNumber: true },
miner: '0xC014BA5EC014ba5ec014Ba5EC014ba5Ec014bA5E',
extraData: '0x',
transactions: [
'0xa7d99198349117987d7897e9a57beb3b8907112385e0f81fbc16e2e6dee7b51a'
],
baseFeePerGas: BigNumber { _hex: '0x2813e03e', _isBigNumber: true },
_difficulty: BigNumber { _hex: '0x020080', _isBigNumber: true }
}
const thash = '0xa7d99198349117987d7897e9a57beb3b8907112385e0f81fbc16e2e6dee7b51a';
await ethers.provider.getTransaction(thash); // トランザクションの内容確認
{
hash: '0xa7d99198349117987d7897e9a57beb3b8907112385e0f81fbc16e2e6dee7b51a',
type: 2,
accessList: [],
blockHash: '0x7646cf9b2762669ac923bc12715cbf490dd98b4ce783080f3c4ce44573db68a7',
blockNumber: 3,
transactionIndex: 0,
confirmations: 1,
from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266',
gasPrice: BigNumber { _hex: '0x2813e03e', _isBigNumber: true },
maxPriorityFeePerGas: BigNumber { _hex: '0x00', _isBigNumber: true },
maxFeePerGas: BigNumber { _hex: '0x32b927ce', _isBigNumber: true },
gasLimit: BigNumber { _hex: '0xc1e7', _isBigNumber: true },
to: '0x5FbDB2315678afecb367f032d93F642f64180aa3',
value: BigNumber { _hex: '0x00', _isBigNumber: true },
nonce: 2,
data: '0x3a5fbaaa0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000374776f0000000000000000000000000000000000000000000000000000000000',
r: '0x24d8a6dece370d24a75a9599b0b079f0ed16824960e081055f38f70f8d5204e3',
s: '0x020ea8d74f516a44209d7a6ffa53d2ec1b65350e3332e72cba0c4573e6140512',
v: 1,
creates: null,
chainId: 31337,
}
Solidityプログラムからログを出力する方法
プログラムからログが吐けるかどうかは開発効率に大きく関わるのでとても重要
前回の記事でSolidityプログラムはログ出力ができないと説明したが、HardHatで開発するとログ出力が可能
Truffleではログ出力は無理なのでTruffleは諦めてHardHatでコントラクト開発する
ログ出力するサンプルコントラクト
https://scrapbox.io/files/617babe3d7f39b001d036114.svg
npx hardhatコマンドでlogという名前の新規プロジェクトを作成すること
hardhat/console.solをインポートするとログ出力できる
code: Greeter.sol
pragma solidity ^0.8.0;
// ログ出力ライブラリのインポートをする
import "hardhat/console.sol";
contract Greeter {
string private _message = "Hello, world!";
function greet() external view returns(string memory) {
return _message;
}
function setMessage(string calldata message) external {
// ログを出力する
console.log("Changing greeting from '%s' to '%s'", _message, message);
_message = message;
}
}
cipepser.icon 神
テストを書く
code: greeter-test.js
const { expect } = require('chai');
const { ethers } = require('hardhat');
describe('Greeter', function () {
let instance;
before(async () => {
const Greeter = await ethers.getContractFactory('Greeter');
instance = await Greeter.deploy();
await instance.deployed();
});
it('Should return the greeting', async function () {
const expected = 'Hello, world!';
expect(await instance.greet()).to.equal(expected);
});
it('Should return the new greeting once it\'s changed', async function () {
const expected = 'Hola, mundo!';
const setGreetingTx = await instance.setMessage(expected);
// マイニングが完了するまで待つ
await setGreetingTx.wait();
expect(await instance.greet()).to.equal(expected);
});
});
テストを実行してコントラクトのコンパイルとデプロイ、コントラクト自体の実行をする
code: bash
$ cd log
$ npx hardhat test
Compiling 2 files with 0.8.4
Compilation finished successfully
Greeter
✓ Should return the greeting
Changing greeting from 'Hello, world!' to 'Hola, mundo!'
✓ Should return the new greeting once it's changed (42ms)
2 passing (1s)
テストを実行すると以下のログが出力される
Changing greeting from 'Hello, world!' to 'Hola, mundo!'
コントラクトに所有権を設定する方法
ここまでに作成したコントラクトは誰でも実行できるものだったけど、コントラクトってそもそも誰でも実行できていいんだっけ
コントラクトにオーナー設定することができる
通常コントラクトをデプロイしたアカウントにオーナーを設定することが多い
Solidityではコントラクトの特定のメソッドをコントラクトのオーナーのみ実行できるように制御することができる
他のアカウントにオーナーを渡すことも可能
openzeppelin/contractsというライブラリを使うと簡単にオーナー周りの制御ができる
openzeppelin/contractsをインストールする
openzeppelin/contractsはEthereum上でスマートコントラクを実装するためのライブラリ
コントラクトに所有権を設定するだけならopenzeppelin/contractsを使わなくても開発できるけど、その他いろいろ便利な機能があったり現状スマートコントラクト開発のデファクトライブラリなのでインストールすることをおすすめする
code: bash
$ npm install @openzeppelin/contracts
所有権を設定するサンプルコントラクト
https://scrapbox.io/files/617e48abbe315d001d95c7f0.svg
npx hardhatコマンドでownableという名前の新規プロジェクトを作成すること
@openzeppelin/contracts/access/Ownable.solをインポートする
Ownableコントラクトを継承したコントラクトは、デプロイしたEOA(Externally Owned Account)がオーナーになる
code: Greeter.sol
pragma solidity ^0.8.0;
// オーナー設定するためのライブラリをインポートする
import "@openzeppelin/contracts/access/Ownable.sol";
// Ownableコントラクトを継承する
contract Greeter is Ownable {
string private _message = "Hello, world!";
function greet() external view returns(string memory) {
return _message;
}
// onlyOwnerを指定するとオーナー以外はこのメソッドを実行できなくなる
function setMessage(string calldata message) external onlyOwner {
_message = message;
}
}
テストを書く
ethers.getSignersメソッドを使うとイーサのアカウントアドレスを取得することができる
配列でアドレスが返ってくる
配列の0番目はオーナーのアドレス
コントラクトオブジェクトのconnectメソッドにアドレスをセットするとそのアドレスでコントラクトを実行することができる
code: greeter-test.js
const { expect } = require('chai');
const { ethers } = require('hardhat');
describe('Greeter', function () {
let instance;
before(async () => {
const Greeter = await ethers.getContractFactory('Greeter');
instance = await Greeter.deploy();
await instance.deployed();
});
it('Should fail if caller is not the owner', async function () {
// アカウントを取得する
// オーナー以外がsetMessageメソッドを実行すると実行が失敗することを確認する
await expect(
// オーナー以外のアカウントでsetMessageメソッドを実行する
instance.connect(other).setMessage('kon nichiwa')
).to.be.revertedWith('Ownable: caller is not the owner');
});
});
テストを実行してコントラクトのコンパイルとデプロイ、コントラクト自体の実行をする
code: bash
$ npx hardhat test
Greeter
✓ Should fail if sender is not owner (42ms)
1 passing (714ms)
Satelliteパターンを使ったコントラクトの更新
コントラクトをデプロイするとバイトコードを含んだトランザクションが作成される
バイトコード=Solidityのプログラムをコンパイルして生成されたバイトコード
コントラクトにアドレスが割り当てられる
一度デプロイしたコントラクトは後から変更することができない
コントラクトの実態がトランザクションなので当たり前といえば当たり前
コントラクトを修正して再デプロイすると別のトランザクションが生成される=アドレスが変わる
コントラクトはアドレスを指定して実行するのでプログラム修正のたびにアドレスが変わるとコントラクトを実行する側のプログラムに修正が必要になる
Satelliteパターンを使うとコントラクトのメソッドの内容を後から更新することができる
元ネタはこちら
Satelliteパターンを実装するサンプルコントラクト
https://scrapbox.io/files/617ba91f6f62d1001d86e4ea.svg
SatelliteコントラクトはcalculateVariableメソッドを持つだけのシンプルなコントラクト
SatelliteのcalculateVariableメソッドの内容を更新しても、コントラクトを使う側は修正なしでいつでも最新のSatelliteコントラクトのcalculateVariableメソッドを呼び出せるようにしたい
code: Satellite.sol
pragma solidity ^0.8.0;
contract Satellite {
function calculateVariable() external pure returns (uint){
return 2 * 3;
}
}
BaseコントラクトはSatelliteコントラクトのプロキシ的なコントラクト
最新のSatelliteコントラクトのアドレスに更新できるupdateSatelliteAddressメソッドを実装する
calculateVariableメソッドを呼ぶと最新のSatelliteコントラクトのcalculateVariableメソッドを実行することができる
code: Base.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/Ownable.sol";
import "./Satellite.sol";
contract Base is Ownable {
// Satelliteコトントラクトのアドレスを保持する
address _addr;
// SatelliteコトントラクトのcalculateVariableメソッドを呼び出す
function calculateVariable() external view returns(uint) {
require(_addr != address(0x0), "Satellite addredd is not set.");
Satellite s = Satellite(_addr);
return s.calculateVariable();
}
// Satelliteコトントラクトのアドレスを更新する
function updateSatelliteAddress(address addr) public onlyOwner {
_addr = addr;
}
}
テストを書く
BaseコントラクトをデプロイしてからSatelliteコントラクトを二つデプロイしてアドレスを入れ替える
code: satellite-test.js
const { expect } = require('chai');
const { ethers } = require('hardhat');
describe('Satellite', function () {
it('Should return same value', async function () {
const Satellite = await ethers.getContractFactory('Satellite');
const s1 = await Satellite.deploy();
await s1.deployed();
const Base = await ethers.getContractFactory('Base');
const base = await Base.deploy();
await base.deployed();
await base.updateSatelliteAddress(s1.address);
const value1 = await base.calculateVariable();
console.log('value: %d', value1);
const s2 = await Satellite.deploy();
await s2.deployed();
await base.updateSatelliteAddress(s2.address);
const value2 = await base.calculateVariable();
console.log('value: %d', value2);
expect(value1).to.equal(value2);
});
});
テストを実行してコントラクトのコンパイルとデプロイ、コントラクト自体の実行をする
code: base
$ npx hardhat test
Satellite
value: 6
value: 6
✓ Should (748ms)
1 passing (751ms)
コントラクトのアップデートについて
Satelliteパターンはコントラクトのメソッドが固定されてしまうのでアップデートできると言っても限定的
Proxyパターンを使う方法もある
以下に実装サンプルがあるので興味のある方は見てみてください
AnonifyはFactoryパターンで実装
まとめ
開発環境はTruffleよりもHardHatの方が人気
開発体験もHardHatの方がよい
ブロックチェーンにデータを保存する方法はかなり独特、Solidityプログラミングの醍醐味といっても良いかも
OpenZeppelinにいろいろ便利な機能があるので積極的に活用するのが良さそう
コントラクトのアップデート問題はわりと大変かつ根が深い
HardHatへの環境乗り換えの説明をした関係でSolidityの実装例が4つしか紹介できませんでした。リポジトリには他にも役に立ちそうな実装をいくつかアップしておいたので興味があれば参考にしてみてください。次はRustのプログラムからWeb3を使ってコントラクトを実行する方法について紹介したいと思います。(文責・藤田) Anonify解体新書 | 連載一覧(全8回)